從多年前開始寫遊戲就發現了,不管是用C寫GBA卡帶遊戲、用C++寫3D戰略遊戲、用Flash寫RPG、用Java寫Applet、用Unity寫VR、還是用TypeScript寫網頁遊戲,第一件工作就是先搞清楚這個開發環境的更新循環系統要怎麼建立。
在網頁遊戲的世界中,我們只要瞭解瀏覽器的運作流程就行了,不過在其他平台上其實也都是類似的概念。在網頁開啟後,瀏覽器會給每一個載入的.js檔一個執行程式碼的機會,隨後網頁就會開始它重繪網頁的無限循環。
雖然小哈的這個系列文章都是用TypeScript撰寫示範專案,不過這些專案實際在執行的時候,是把TypeScript轉譯成JavaScript,然後放進JavaScript的執行環境去運作。
那麼我們要如何在瀏覽器給我們第一次執行程式碼的機會過後,再次執行我們寫的程式呢?一般網頁可以使用瀏覽器提供的setTimeout()或setInterval()函式,規畫多少時間之後要執行哪個函式,這些規畫期程的函式會在瀏覽器『喘口氣』的時間被依序執行。
setTimeout(function, milliseconds),可以規畫一段時間後要執行哪個函式。它的第一個參數是計畫要執行的函式, 第二個參數是多少毫秒後執行。
setInterval(function, milliseconds),跟setTimeout很像,不過是每隔一段時間會重覆執行第一個參數所給的函式。
setTimeout和setInterval都有同樣的問題,這邊舉個例給大家理解這個問題的嚴重性。假設我們要以30fps的更新速率,讓角色每一幀跑10個像素,那麼我們可以如下利用setInterval來設定一個更新的函式。
/** 假設我們有一個角色的類別: Actor */
let myActor = new Actor();
/** 每一幀的更新函式 */
let moveUpdate() {
// 將角色往右移10個像素
myActor.x += 10;
}
/** 設定每33毫秒執行一次moveUpdate
* 這樣一秒鐘理論上會執行1000/33 ≒ 30fps
*/
setInterval(moveUpdate, 33);
想像很美好,現實很殘酷。瀏覽器雖然會儘量趕在33毫秒後的下一次『喘口氣』的時間執行moveUpdate,但是遲到的時間它是記不住的,在執行完moveUpdate後不管剛剛遲到多久,仍要等到下一個33毫秒後的『喘口氣』才會再次執行。也就是說,如果setInterval第一次執行函式時離遊戲開始過了40毫秒,那麼再下一次執行時,就是遊戲開始後的80毫秒,再下一次就是120毫秒,第四次就是160毫秒。
理想中,四幀應該只要花33*4=132毫秒,五幀花33*5=165毫秒,但在理想中第五幀的時候,遊戲實際上才執行了四次更新。五幀就掉了一幀,這樣絕不是我們遊戲設計師能夠容忍的,是吧。
瀏覽器提供了一個特別的函式,讓我們可以在它每次『喘口氣』的時候,都執行一次我們指定的函式。
這個函式可以將我們寫的函式排入瀏覽器下次『喘口氣』時執行的函式列表。注意喔!只有下次。
所以我們使用requestAnimationFrame()來建立一個無很循環的更新系統時,每次執行函式後都要再次呼叫requestAnimationFrame(),才會再次得到下次被執行的機會。
function gameUpdate() {
...
/** 使用requestAnimationFrame()來取得下次執行gameUpdate的機會
*/
requestAnimationFrame(gameUpdate);
}
/** 在第一次獲得瀏覽器給的執行機會時
* 使用requestAnimationFrame()來取得下次執行gameUpdate的機會
*/
requestAnimationFrame(gameUpdate);
另外要記得一點,就是requestAnimationFrame()會回傳一個整數,作為這次排程的收據。如果之後想要取消這次的排程,可以呼叫cancelAnimationFrame(收據)來取消。
// 使用requestAnimationFrame()來取得下次執行gameUpdate的機會
let receipt = requestAnimationFrame(gameUpdate);
// 取消剛剛的排程
cancelAnimationFrame(receipt);
知道了怎麼在瀏覽器中安插自己的更新函式,我們就可以來設計遊戲用的更新循環系統了。
這邊列一下我們的更新循環系統應該要有哪些功能。
其實應該還可以有更多功能,比如可以加入類似setInterval()的功能等等,但是這邊寫太多就顯得太不信任同學們的能力,所以其他的就交給大家自行去發揮吧。
/** 定義一下可以放入這個系統排程的函式型別
* 回傳值隨便什麼都行(any),反正我們也不會管。
* 同學們在設計自己的系統時,可以塞給這些函式一些重要的參數,
* 比如目前的系統時間什麼的。
* 在這邊我設計得比較單純,沒給任何參數。
*/
type UpdaterFunction = () => any;
/** 將這個更新循環系統命名為Updater */
class Updater {
// 預設每秒執行30次(frame per second)
fps = 30;
// 需要執行的函式列表
funcsToRun: UpdaterFunction[] = [];
// 啟動時的系統時間
startSystemTime = 0;
// 目前遊戲累積經過的時間
currentTime = 0;
// 目前幀數
currentFrame = 0;
// 目前是否正在運作中, 預設是 false
running = false;
// 記錄requestAnimationFrame給的收據
animFrameID = 0;
// 開始運作,參數是fps
start(fps: number): void {
this.fps = fps;
// 重置時間和屬性,
// performance.now()的時間和requestAnimationFrame()是對齊的
this.startSystemTime = performance.now();
this.currentTime = 0;
this.currentFrame = 0;
// 開始嘍
this.running = true;
// 要求瀏覽器下次喘氣時呼叫我的this.update()
this.animFrameID = requestAnimationFrame(this.update);
}
// 停止運作
stop(): void {
this.running = false;
cancelAnimationFrame(this.animFrameID);
}
// 加入排程的函式
addFunction(func: UpdaterFunction): void {
this.funcsToRun.push(func);
}
// 移除排程的函式
removeFunction(func: UpdaterFunction): void {
// 先找到這個函式在陣列中的位置
let index = this.funcsToRun.indexOf(func);
// 如果位置不是-1,代表找到了
if(index != -1) {
/** 將這個位置上的元素改為null。
* 不直接將這個元素從陣列中移除是有原因的,下面再談。
*/
this.funcsToRun[index] = null;
}
}
/** 將目前幀數前進一格,並執行所有排程的函式 */
advanceFrame() {
this.currentFrame++;
// 執行所有排程的函式
let length = this.funcsToRun.length;
for(let i = 0; i < length; i++) {
let func = this.funcsToRun[i];
// func有可能在removeFunction裏被設為null
// 所以要先確定func不是null才去執行
if(func) {
func();
} else {
// 如果func是null,我們就把這個陣列的位置刪掉
// 呼叫Array.splice,從i這個位置開始註後刪1個元素
this.funcsToRun.splice(i, 1);
// 等一下i馬上會i++
// 所以要先讓i回到上個位置,等一下才會回到正確的下個位置
i--;
}
}
}
/** 重點來了,本系統的更新函式
* 這裏必須使用箭頭函式,原因留到下面再講
* requestAnimationFrame在呼叫我們的this.update時
* 會附送一個目前時間的參數,我們將這參數命名為now
*/
update = (now: number) => {
// 首先,先安排this.update下一幀還要被呼叫
this.animFrameID = requestAnimationFrame(this.update);
// 計算從開始到現在過了多久
this.currentTime = now - this.startSystemTime;
// 計算現在應該要執行第幾幀了
let nextFrame = this.currentTime * 0.001 * this.fps;
// 如果目前幀數小於應該要執行的幀數,那就將目前幀數前進一格
if(this.currentFrame < nextFrame) {
this.advanceFrame();
// 如果前進一幀之後,還是落後,那麼再前進一幀
if(this.currentFrame < nextFrame) {
// 這是電腦因為某些緣故變慢時的補救措施
// 這裏寫的是最簡單的補救方法
// 同學們可以依需求想出別的方法
this.advanceFrame();
}
}
}
}
這樣,我們的更新循環系統就完成了,實際使用的方法如下。
/** 先定義一個遊戲的類別 */
class Game {
// 這邊也要用箭頭函式,原因下面再說。
updateActors = () => {
// 空的更新函式,純示範
}
}
// 建立遊戲
let game = new Game();
// 新增一個更新循環系統
let updater = new Updater();
// 開始運作
updater.start(30);
// 排入遊戲的更新函式
updater.addFunction(game.updateActors);
// 這樣game.updateActors就會每秒被執行30次了
上面的示範程式留下了兩個謎團:
如果有製作遊戲經驗的朋友,肯定已經知道原因了,因為這是遊戲製作中很常見的問題。
遊戲中的物件之間牽扯緊密,有時更新一個物件,卻影響到其他十多個物件的情況也不奇怪。比如說有一顆炸彈爆炸了,在更新這個爆炸的時候,同時讓三個角色死亡,並移除附近的二十朵花,外加新增五團煙霧和一個在地板的焦黑痕跡。
請大家想想喔,在炸彈爆炸的時候,除了炸彈本身移除時需要去更新循環系統移除自己的更新函式,同時也可能為了角色死亡以及花被炸碎,而去移除他們各自的更新函式。
也就是說,在advanceFrame()裏面執行所有排程的函式的過程中,如果我們在for迴圈還未結束前隨意刪除函式陣列裏的函式,導至陣列裏函式的位置變來變去,那麼就會發生某些函式被跳過沒執行到,或某些函式被執行兩次的慘劇。
因此在移除函式時,先設null,然後由Updater來進行實際移除的工作才安全。
在這系列的第一篇《Trick 1: 萬惡的摸彩箱》也有提到箭頭函式,不過當時大家所知不多,我只簡略提了一下關鍵字this用在一般函式和箭頭函式裏有不同的意義。
這裏我們用一個更容易體會的例子,來解釋給同學們聽。
/** 定義遊戲的類別 */
class Game {
name = "一場遊戲一場夢";
// 一般函式
update1() {
console.log(this.name);
}
// 箭頭函式
update2 = () => {
console.log(this.name);
}
}
// 建立遊戲
let game = new Game();
// 新增一個更新循環系統
let updater = new Updater();
// 開始運作
updater.start(30);
// 排入遊戲的更新函式
updater.addFunction(game.update1);
updater.addFunction(game.update2);
在上面的例子中,game的兩個函式都會被updater呼叫,但是在執行update1()時會跳出錯誤,表示this裏面沒有"name"這個屬性,而在執行update2()時卻能正常列印出"一場遊戲一場夢"。
會有這樣的結果,是因為當我們把update2這個箭頭函式交給updater去執行的時候,updater2記得自己的主人是game,所以在使用this.name的時候,可以正確地在game裏面找到"name"這個屬性。
但是把update1交給updater去執行的時候,updater1不會記得他的主人是game,所以在使用this.name的時候,發現this不知道是什麼(undefined),進而發生錯誤。
以上這兩項說明,都是初學者非常容易搞出BUG卻找不到問題所在的地方,所以這邊特別在這次的範例中提出來,希望同學們能少走點冤枉路。